iT邦幫忙

2026 iThome 鐵人賽

DAY 2
0
AI 自動化

協定、框架、架構:一條龍搞懂 AI Agent 是怎麼被造出來的系列 第 2

Day 2:型別提示、dataclass 與 Pydantic,你以後每天都會用到的三十行

  • 分享至 

  • xImage
  •  

昨天先把本機模型選好,今天開始補第一塊地基。

看到「型別提示、dataclass、Pydantic」可能會覺得有點基礎,但如果後面要碰 MCP,這幾個東西其實很難繞過。

原因很簡單。

之後我們會寫出這種 Tool:

@mcp.tool()
def read_file(path: str, max_lines: int = 200) -> str:
    """讀取專案內的一個檔案並回傳內容。"""

對 Python 來說,這只是一個 function。

但對 LLM 來說,它需要知道:

  • 這個工具叫什麼?
  • 可以傳哪些參數?
  • 參數是什麼型別?
  • 哪些是必填?
  • 有沒有範圍限制?

這些資訊最後都會變成一份 JSON Schema。

所以今天想搞懂的其實不是 Pydantic 語法本身,而是:

Python 的型別,最後是怎麼變成 LLM 看得懂的 Tool 定義?


從最簡單的 dict 開始

最直接的寫法當然是:

msg = {
    "role": "user",
    "content": "哈囉"
}

簡單又方便。

但如果哪天手滑:

msg = {
    "rols": "user",
    "content": "哈囉"
}

Python 不會有任何反應。

這份資料可能一路往下傳,直到其他地方真的去讀 role 時才爆掉。

麻煩的地方就是:

寫錯的地方,跟發現錯誤的地方可能差很遠。


TypedDict:至少編輯器看得懂了

可以先加上 TypedDict

from typing import Literal, TypedDict

class MessageDict(TypedDict):
    role: Literal["system", "user", "assistant", "tool"]
    content: str

這時 VS Code、mypy 這些工具就能開始幫忙檢查。

但它主要還是開發階段的保護。

例如:

bad: MessageDict = {
    "role": "banana",
    "content": 123
}

Type Checker 會不開心,但 Python 執行時還是能跑。

所以 TypedDict 解決的是:

「寫程式時提醒我」

不是:

「執行時幫我擋掉錯誤資料」


dataclass:資料開始有自己的結構

再往前一步:

from dataclasses import dataclass, field

@dataclass
class Message:
    role: str
    content: str = ""
    tags: list[str] = field(default_factory=list)

這樣比 dict 舒服很多。

不用自己寫 __init__,印出來也比較清楚,兩個內容相同的物件甚至可以直接比較。

但如果我這樣寫:

wrong = Message(
    role=999,
    content=["這不是字串"]
)

還是可以建立成功。

因為 Python 的型別標註,本身不代表執行時一定會驗證。

如果資料完全是程式內部自己產生的,其實 dataclass 已經很好用了。

但 Agent 有一個問題:

Tool 的參數很多時候是 LLM 產生的。

既然資料不是我們完全控制的,就需要多一道驗證。


Pydantic 開始派上用場

假設之後要做一個 read_file 工具:

from typing import Literal

from pydantic import BaseModel, Field, field_validator


class ReadFileParams(BaseModel):
    path: str = Field(
        description="相對於專案根目錄的檔案路徑,例如 src/main.py"
    )

    max_lines: int = Field(
        default=200,
        ge=1,
        le=5000,
        description="最多讀取幾行"
    )

    encoding: Literal["utf-8", "big5"] = Field(
        default="utf-8",
        description="檔案編碼"
    )

    @field_validator("path")
    @classmethod
    def no_escape(cls, value: str) -> str:
        if ".." in value or value.startswith("/"):
            raise ValueError("路徑必須是相對路徑")
        return value

這時候就跟前面不一樣了。

Pydantic 不只記錄型別,還真的會驗證資料。

例如:

ReadFileParams(path="../../etc/passwd")

會直接被擋。

這個:

ReadFileParams(
    path="a.py",
    max_lines=99999
)

也不會通過。

如果 encoding 傳了一個不在選項裡的值,一樣會失敗。

對 Agent 來說這很重要,因為我們不能假設:

LLM 每一次產生的 Tool Call 都一定正確。


它也會幫忙處理部分型別問題

例如:

params = ReadFileParams(
    path="README.md",
    max_lines="80"
)

原本的 "80" 是字串。

Pydantic 驗證後會轉成:

80

也就是 int

LLM 產生 Tool Call 時,參數格式不一定永遠跟我們預期的一模一樣。

所以比起讓每一支 Tool 自己處理輸入,我比較希望先統一經過一層驗證。


最重要的是這一行

前面講的東西,真正跟 MCP 接起來的是:

schema = ReadFileParams.model_json_schema()

Pydantic 會直接幫我們產生類似:

{
  "properties": {
    "path": {
      "description": "相對於專案根目錄的檔案路徑,例如 src/main.py",
      "type": "string"
    },
    "max_lines": {
      "default": 200,
      "minimum": 1,
      "maximum": 5000,
      "type": "integer"
    },
    "encoding": {
      "default": "utf-8",
      "enum": [
        "utf-8",
        "big5"
      ],
      "type": "string"
    }
  },
  "required": [
    "path"
  ],
  "type": "object"
}

前面的東西就全部接起來了:

Field description
        ↓
description

ge / le
        ↓
minimum / maximum

Literal
        ↓
enum

沒有預設值
        ↓
required

這份 Schema 就是之後描述 Tool 參數的重要資訊。

現在先記住這條線:

Python Type Hint
        ↓
Pydantic Model
        ↓
JSON Schema
        ↓
Tool 定義

到了 Day 7 講 MCP Tools 時,我們會正式看到這份 Schema 在 MCP 裡扮演什麼角色。

到了 Day 10 真正用 Python SDK 寫 MCP Server,再回頭看:

@mcp.tool()

就不會覺得它像什麼黑魔法了。


先做一個自己的 ToolSpec

後面會一直用 Tool,所以我先做一層自己的資料結構:

class ToolSpec(BaseModel):
    name: str
    description: str
    input_schema: dict

    @classmethod
    def from_pydantic(
        cls,
        name: str,
        description: str,
        params: type[BaseModel]
    ):
        return cls(
            name=name,
            description=description,
            input_schema=params.model_json_schema()
        )

之後只要:

spec = ToolSpec.from_pydantic(
    name="read_file",
    description="讀取專案內的一個檔案",
    params=ReadFileParams
)

就能把一個 Pydantic Model 轉成 Tool 定義。

這份 ToolSpec 後面會繼續拿去接 Ollama,也會再接進 MCP。

我比較想維持這種做法:

專案裡先有自己的中立格式,再去轉成不同框架需要的格式。

這樣後面就算換模型或換框架,也不用整個專案跟著重寫。


Schema 不是最後一道防線

這裡有一個很容易搞混的地方。

假設我們寫:

max_lines: int = Field(
    ge=1,
    le=5000
)

Schema 裡確實會出現:

{
  "minimum": 1,
  "maximum": 5000
}

模型看到之後,會知道這個參數大概應該怎麼填。

但這不代表模型一定不會填錯。

所以我會把它拆成兩層:

JSON Schema
    ↓
告訴模型參數應該怎麼填

Pydantic
    ↓
程式實際驗證收到的資料

現在先記住一件事就好:

不要因為資料是 LLM 產生的,就預設它一定正確。

等到 Day 19 講 Agent 的安全治理與最小權限時,再把這件事繼續往 Tool 權限與高風險操作延伸。


dataclass 還是 Pydantic?

目前我自己的判斷方式其實很簡單。

如果資料完全來自自己的程式:

dataclass

通常就夠了。

如果資料可能來自:

使用者輸入
LLM Tool Call
API Response
外部服務

我就會比較傾向:

Pydantic

不用因為 Pydantic 很方便,就什麼東西都塞進去。

該簡單的地方還是簡單一點。


Day 2 小結

今天其實只是在建立一條後面會一直出現的路:

Python Type Hint
        ↓
Pydantic Model
        ↓
JSON Schema
        ↓
Tool 定義
        ↓
LLM 產生 Tool Call
        ↓
程式再次驗證

Day 7 講 MCP Tools 時,會正式把這份 Schema 接進 MCP。

Day 10 寫第一個 MCP Server 時,則會看到 SDK 怎麼幫我們把這些步驟包起來。

所以今天表面上是在學 Pydantic。

實際上是在幫後面的 MCP Tool 打地基。


明天:Day 3

下一篇:

async / await 與 asyncio,順便量出這張卡的天花板。

除了把非同步搞懂,我也會直接實測同一張 GPU 在不同並行數下的表現。

因為後面做到 Multi-Agent 時,一個很現實的問題一定會出現:

Agent 開得更多,真的就會跑得更快嗎?

明天直接測!!!


上一篇
Day 1:一台本機 GPU、三十天、零 API 費用
下一篇
Day 3:async / await 與 asyncio,順便量出這張卡的天花板
系列文
協定、框架、架構:一條龍搞懂 AI Agent 是怎麼被造出來的10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言